package pl.kaczmarz.audiosteganography;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteOrder;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileChannel.MapMode;

/**
 *
 * @author Pawel
 */
public class WaveFile implements AutoCloseable {

	private String fileName;
	private RandomAccessFile file;
	private FileChannel fileChannel;
	private MappedByteBuffer bufferedFile;
	private ByteConverter converter;
	private int numberOfChannels;
	private long blocksPerSecond;
	private long bytesPerSecond;
	private int blockAlign;
	private int bitsPerSample;
	private int bytesPerSample;
	private long numberOfBlocks;
	private long sampleDataStart;
	private byte[] sampleBuffer;

	public int getNumberOfChannels() {
		return numberOfChannels;
	}

	public void setNumberOfChannels(int numberOfChannels) {
		this.numberOfChannels = numberOfChannels;
	}

	public long getBlocksPerSecond() {
		return blocksPerSecond;
	}

	public void setBlocksPerSecond(long blocksPerSecond) {
		this.blocksPerSecond = blocksPerSecond;
	}

	public long getBytesPerSecond() {
		return bytesPerSecond;
	}

	public void setBytesPerSecond(long bytesPerSecond) {
		this.bytesPerSecond = bytesPerSecond;
	}

	public int getBlockAlign() {
		return blockAlign;
	}

	public void setBlockAlign(int blockAlign) {
		this.blockAlign = blockAlign;
	}

	public int getBitsPerSample() {
		return bitsPerSample;
	}

	public void setBitsPerSample(int bitsPerSample) {
		this.bitsPerSample = bitsPerSample;
	}

	public long getSampleDataStart() {
		return sampleDataStart;
	}

	public void setSampleDataStart(long sampleDataStart) {
		this.sampleDataStart = sampleDataStart;
	}

	public int getBytesPerSample() {
		return bytesPerSample;
	}

	public void setBytesPerSample(int bytesPerSample) {
		this.bytesPerSample = bytesPerSample;
	}

	public long getNumberOfBlocks() {
		return numberOfBlocks;
	}

	public void setNumberOfBlocks(long numberOfBlocks) {
		this.numberOfBlocks = numberOfBlocks;
	}

	public long getFileLength() {
		try {
			return file.length();
		} catch (IOException ex) {
			return 0;
		}
	}

	private void setFileLenght(long length) throws IOException {
		file.setLength(length);
	}

	public String getFileName() {
		return this.fileName;
	}

	public void setFileName(String fileName) {
		this.fileName = fileName;
	}

	private ByteConverter getConverter() {
		return this.converter;
	}

	private void setConverter(ByteConverter converter) {
		this.converter = converter;
	}

	public int milisecondsToBlocks(double miliseconds) {
		return (int) (getBlocksPerSecond() * miliseconds / 1000);
	}

	public double blocksToMiliseconds(int blocks) {
		return 1000.0 * blocks / getBlocksPerSecond();
	}

	private WaveFile() {}

	public static WaveFile open(String fileName, String mode)
			throws UnsupportedWaveFormatException, UnexpectedValueException,
			FileNotFoundException, IOException {

		WaveFile wave = new WaveFile();

		try {
			wave.setFileName(fileName);

			switch (mode) {
				case "r":
					wave.file = new RandomAccessFile(wave.getFileName(), "r");
					wave.fileChannel = wave.file.getChannel();
					wave.bufferedFile = wave.fileChannel.map(MapMode.READ_ONLY, 0, wave.file.length());
					break;
				case "rw":
					wave.file = new RandomAccessFile(wave.getFileName(), "rw");
					wave.fileChannel = wave.file.getChannel();
					wave.bufferedFile = wave.fileChannel.map(MapMode.READ_WRITE, 0, wave.file.length());
					break;
				default:
					throw new IllegalArgumentException(mode);
			}



			byte[] dwordBuffer = new byte[4];
			byte[] wordBuffer = new byte[2];
			wave.setConverter(new ByteConverter(ByteOrder.LITTLE_ENDIAN, false));

			// First 4 bytes must equal "RIFF"
			wave.bufferedFile.get(dwordBuffer);

			if ( ! new String(dwordBuffer).equals("RIFF")) {
				throw new UnexpectedValueException(wave.getFileName(), wave.bufferedFile.position() - 4, new String(dwordBuffer), "RIFF");
			}

			// next 4 bytes is chunksize, we can skip this
			wave.bufferedFile.position(wave.bufferedFile.position() + 4);

			// next 4 bytes must equal "WAVE"
			wave.bufferedFile.get(dwordBuffer);

			if ( ! new String(dwordBuffer).equals("WAVE")) {
				throw new UnexpectedValueException(wave.getFileName(), wave.bufferedFile.position() - 4, new String(dwordBuffer), "WAVE");
			}

			// next 4 bytes must equal "fmt "
			wave.bufferedFile.get(dwordBuffer);

			if ( ! new String(dwordBuffer).equals("fmt ")) {
				throw new UnexpectedValueException(wave.getFileName(), wave.bufferedFile.position() - 4, new String(dwordBuffer), "fmt ");
			}

			// next 4 bytes - the chunk size, it must equal 16 for WAVE_FORMAT_PCM
			wave.bufferedFile.get(dwordBuffer);

			if (wave.converter.toLong(dwordBuffer) != 16) {
				wave.bufferedFile.get(wordBuffer);
				throw new UnsupportedWaveFormatException(wave.converter.toInt(wordBuffer));
			}

			// Next 2 bytes - format tag, must equal 1 for PCM
			wave.bufferedFile.get(wordBuffer);

			if (wave.converter.toInt(wordBuffer) != 1) {
				throw new UnsupportedWaveFormatException(wave.converter.toInt(wordBuffer));
			}

			// Next 2 bytes - Number of interleaved channels.
			wave.bufferedFile.get(wordBuffer);
			wave.setNumberOfChannels(wave.converter.toInt(wordBuffer));

			// Next 4 bytes - Sampling rate (blocks per second).
			wave.bufferedFile.get(dwordBuffer);
			wave.setBlocksPerSecond(wave.converter.toLong(dwordBuffer));

			// Next 4 bytes - Data rate (average bytes per second).
			wave.bufferedFile.get(dwordBuffer);
			wave.setBytesPerSecond(wave.converter.toLong(dwordBuffer));

			// Next 2 bytes - block align, length of the block in bytes
			wave.bufferedFile.get(wordBuffer);
			wave.setBlockAlign(wave.converter.toInt(wordBuffer));

			// Next 2 bytes - Bits per sample
			wave.bufferedFile.get(wordBuffer);
			wave.setBitsPerSample(wave.converter.toInt(wordBuffer));

			wave.setBytesPerSample(wave.getBlockAlign() / wave.getNumberOfChannels());

			// Skipping non-data RIFF chunks
			while (true) {
				wave.bufferedFile.get(dwordBuffer);

				String blockName = new String(dwordBuffer);

				if (blockName.equals("data")) {
					// Next 4 bytes - block size - number of sample data bytes
					wave.bufferedFile.get(dwordBuffer);
					wave.setNumberOfBlocks(wave.converter.toLong(dwordBuffer) / wave.getBlockAlign());
					break;
				} else {
					// Non-data block, skip the whole block
					wave.bufferedFile.get(dwordBuffer);

					int len = wave.converter.toInt(dwordBuffer);
					System.out.println("BLOCK LENGTH: " + len);

					if (len % 2 == 1) {
						++len;
					}

					if (wave.bufferedFile.position() + len < wave.file.length()) {
						wave.bufferedFile.position(wave.bufferedFile.position() + len);
					} else {
						throw new UnsupportedWaveFormatException(wave.converter.toInt(wordBuffer));
					}
				}
			}

			wave.setSampleDataStart(wave.bufferedFile.position());

			// Set ByteConverter to use accurate format for samples:
			if (wave.getBitsPerSample() > 8) {
				wave.converter.setSigned(true);
			} else {
				wave.converter.setSigned(false);
			}

			wave.converter.setByteOrder(ByteOrder.LITTLE_ENDIAN);

			wave.sampleBuffer = new byte[wave.getBytesPerSample()];

			return wave;
		} catch (FileNotFoundException | UnexpectedValueException | UnsupportedWaveFormatException ex) {
			if (wave != null) {
				wave.close();
				wave = null;
			}

			throw ex;
		}
	}

	public static void copy(String source, String destination) throws FileNotFoundException, IOException {

		try (RandomAccessFile input = new RandomAccessFile(source, "r");
				RandomAccessFile output = new RandomAccessFile(destination, "rw");) {

			output.getChannel().transferFrom(input.getChannel(), 0, input.getChannel().size());
		}
	}

	public void close() throws IOException {
		if (fileChannel != null) {
			fileChannel.close();
		}

		fileChannel = null;

		if (file != null) {
			file.close();
		}

		file = null;
	}

	public void seek(int channel, int sample) {
		bufferedFile.position((int) (getSampleDataStart() + sample * getBlockAlign() + channel * getBytesPerSample()));
	}

	public int readSample() {
		bufferedFile.get(sampleBuffer);
		return converter.toInt(sampleBuffer);
	}

	public int readSample(int channel, int sample) {
		this.seek(channel, sample);
		return this.readSample();
	}

	public int[] readSamples(int channel, int offset, int length) {
		int[] samples = new int[length];

		for (int i = 0; i < length; ++i) {
			samples[i] = readSample(channel, offset + i);
		}

		return samples;
	}

	public void readSamples(int channel, int offset, int[] output) {
		for (int i = 0; i < output.length; ++i) {
			output[i] = readSample(channel, offset + i);
		}
	}

	public void writeSample(int value) {
		converter.toBytes(value, sampleBuffer);
		bufferedFile.put(sampleBuffer);
	}

	public void writeSample(int channel, int offset, int value) {
		this.seek(channel, offset);
		this.writeSample(value);
	}

	// reads the whole block, returns samples for all channels for the given offset.
	public void readBlock(int offset, int[] result) {
		seek(0, offset);
		for (int channel = 0; channel < getNumberOfChannels(); ++channel) {
			result[channel] = readSample();
		}
	}

	public void writeBlock(int offset, int[] block) {
		seek(0, offset);
		for (int sample : block) {
			writeSample(sample);
		}
	}

	@Override
	public String toString() {
		return "WaveFile{" + "numberOfChannels=" + numberOfChannels + ", blocksPerSecond=" + blocksPerSecond + ", bytesPerSecond=" + bytesPerSecond + ", blockAlign=" + blockAlign + ", bitsPerSample=" + bitsPerSample + ", bytesPerSample=" + bytesPerSample + ", numberOfBlocks=" + numberOfBlocks + ", sampleDataStart=" + sampleDataStart + '}';
	}
}
